# import export使用
30 秒速记
ESM(import/export)是静态的:编译期就确定依赖,能做tree shaking;CJS(require)是运行时执行到才加载ESM导出是活绑定,导出方改了值导入方能看到;CJS拿到的是module.exports那一刻的值Node怎么判断文件是哪种:.mjs一定是ESM,.cjs一定是CJS,.js看最近的package.json里"type"- 版本线:
Node 8.5/9要--experimental-modules,12.17/13.2起不用flag,14以后稳定 ESM里没有require、__dirname,要用import.meta.url或Node 20.11+的import.meta.dirname
import / export 是 JS 官方的模块标准 ESM,require 是 Node 早年自己搞的 CommonJS,两者最大的区别是一个静态、一个动态。 ESM 在代码运行前就把依赖关系分析好了,所以 import 只能写在顶层,打包工具也能据此删掉没用到的代码;CJS 是执行到 require 那行才去加载,可以写在 if 里。Node 对 ESM 的支持走了好几年:Node 9 时代要加实验 flag、文件必须叫 .mjs;现在只要 package.json 写 "type": "module",.js 文件就能直接用 import。
理解「import export使用」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 module 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
Node 早期只有 CommonJS,ES2015 定下了 import / export 标准之后,Node 花了好几个大版本才把两套体系接到一起。按时间看大致是:
Node 8.5/9:加--experimental-modules才能用,ESM文件必须是.mjs,import一个CJS模块只能拿到默认导出Node 12:引入package.json的"type": "module",.js也能当ESMNode 12.17/13.2:去掉flag,14起正式稳定Node 20.11+:ESM里有了import.meta.dirname/import.meta.filenameNode 22.12/20.19:CJS里可以直接require同步的ESM模块
两种写法放一起对比最直观:
// counter.mjs(ESM)
export let count = 0
export function inc() { count++ }
// main.mjs
import { count, inc } from './counter.mjs'
inc()
console.log(count) // 1,活绑定,跟着原变量走
// counter.cjs(CommonJS)
let count = 0
module.exports = { count, inc() { count++ } }
// main.cjs
const { count, inc } = require('./counter.cjs')
inc()
console.log(count) // 0,解构时拷贝的是当时的值
在 ESM 里,__dirname、__filename、require 都没有了:
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Node 20.11+ 可以直接用 import.meta.dirname
新项目我建议直接 "type": "module",老项目迁移时注意第三方包是不是纯 ESM、有没有依赖 __dirname 和 require.cache 的代码,这几处是最常卡住的地方。
面试官追问
追问 1a.js 里 export let count = 0 再导出一个 inc(),b.js 调 inc() 后读 count 是几?
是 1,ESM 的导入是指向原变量的活绑定。换成 CJS 的 module.exports = { count, inc },b.js 读到的还是 0,因为导出的是当时值的拷贝。
追问 2项目里 .js 文件写了 import,一运行报 Cannot use import statement outside a module,怎么办?
Node 把这个 .js 当成 CJS 了。在 package.json 里加 "type": "module",或者把文件改名成 .mjs。加了 type 之后,项目里还用 require 的文件要改成 .cjs。
追问 3在 ESM 文件里能直接 require 一个包吗?
不能,ESM 作用域里没有 require。要么直接 import,CJS 包的 module.exports 会成为默认导出;实在要用就 import { createRequire } from 'node:module' 造一个出来。
追问 4反过来,CJS 文件里能 require 一个纯 ESM 的包吗?
老版本不行,会报 ERR_REQUIRE_ESM,只能用 await import()。Node 22.12 和 20.19 以后默认支持 require(esm),但前提是那个模块里没有顶层 await。
追问 5a 导入 b,b 又导入 a,运行时 b 里拿到的 a 的导出是什么?
ESM 里如果 b 在 a 执行完之前就访问 a 的 let / const 导出,会报 ReferenceError(暂时性死区);CJS 里拿到的是 a 还没执行完的半成品 exports,字段是 undefined。两种都是信号:该把公共部分抽到第三个模块里了。
# 前言
30 秒速记
Node 9能在实验flag下直接跑import/export,不用再靠Babel转- 当时是
Stability: 1 - Experimental,API随时可能变,不适合直接上生产 - 这是历史阶段:现在
ESM早已稳定,flag和.mjs强制要求都不再需要 - 看旧教程要先对版本:
Node 9的写法放到Node 18+上很多已经不适用
Node 9 的意义在于第一次能不经过 Babel,在 flag 模式下直接用 import / export 写服务端代码。 不过当时它还是实验特性,启动参数、文件后缀、loader 的写法后来都改过好几轮。现在回头看这段,我会把它当成了解 Node 模块演进的历史,而不是照抄的配置:Node 14 以后 ESM 已经稳定,新项目在 package.json 里写一句 "type": "module" 就能用,不用任何 flag。
Node 9最激动人心的是提供了在flag模式下使用ECMAScript Modules,虽然现在还是Stability: 1 - Experimental阶段,但是可以让Noder抛掉babel等工具的束缚,直接在Node环境下愉快地去玩耍import/export
如果觉得文字太多,看不下去,可以直接去玩玩demo,地址是https://github.com/chenshenhai/node-modules-demo
面试官追问
追问 1有人照着这篇在 Node 20 上加 --experimental-modules 启动,有必要吗?
没必要。这个 flag 在 Node 12.17 / 13.2 以后就不需要了,ESM 默认开启。现在只要文件是 .mjs 或者 package.json 里有 "type": "module" 就行。
追问 2当年为什么大家都用 Babel 来写 import?
Node 原生不支持,Babel 把 import 转成 require 再交给 Node 跑。代价是多一层构建、调试要靠 sourcemap,而且转出来的其实还是 CJS 语义,活绑定这些特性并不完全一样。
追问 3实验特性在什么情况下能用到生产?
一般不建议,Stability: 1 的 API 不受语义化版本保护,小版本升级都可能改行为。真要用就锁死 Node 版本,并且准备好回退到 Babel 或 require 的方案。
追问 4浏览器里的 import 和 Node 里的 import 行为完全一样吗?
语法一样,解析规则不一样。浏览器只认完整 URL 或相对路径,裸写 'react' 要靠 importmap;Node 能按 node_modules 找包,还有 package.json 的 exports 字段控制入口。
